Skip to content

fix(rocm): make CPU/Hybrid MoE graph replay safe - #378

Draft
zihaomu wants to merge 10 commits into
FlashML-org:mainfrom
zihaomu:fix/issue-350-rocm-cpu-moe-graph
Draft

fix(rocm): make CPU/Hybrid MoE graph replay safe#378
zihaomu wants to merge 10 commits into
FlashML-org:mainfrom
zihaomu:fix/issue-350-rocm-cpu-moe-graph

Conversation

@zihaomu

@zihaomu zihaomu commented Sep 4, 2026

Copy link
Copy Markdown

Summary

Fixes #350, where CPU/Hybrid MoE produces silently incorrect output during ROCm graph replay while eager execution remains correct.

  • fail closed on ROCm: if the native stream-memory handshake does not pass a real capture/instantiate/replay probe, disable graph capture and continue on the correct eager path
  • add the ROCm 7.14 native handshake with hipMallocSignalMemory and explicit graph batch-memory-op nodes
  • keep HIP signal and graph-node parameter storage owned by CpuMoeExecutor, so their lifetime covers every graph replay without module-global growth
  • size per-layer flag capacity from the actual configured graph batch sizes, including configurations with more than 16 sizes
  • preserve the established CUDA module-level memop_submit/memop_sync path to limit NVIDIA regression risk
  • document the CUDA/ROCm synchronization difference and the ROCm fallback behavior

Dependency and scope

Depends on #132. This branch is based directly on the current #132 head (c0713e4). #132 itself is unchanged.

This is intentionally a Draft stacked PR while the native ROCm graph path receives RDNA3 and long-running model-serving stability validation. Until #132 merges, GitHub will also show the dependency commits in this PR; the diff will collapse to this single follow-up commit after the base lands.

CUDA and ROCm retain the same externally visible CPU-MoE ordering and results. Only their GPU/CPU synchronization implementations differ:

Backend Handshake implementation
CUDA Existing mapped-pinned per-slot arrays through module-level cuStreamWriteValue64/cuStreamWaitValue64 wrappers
ROCm 7.14 Executor-owned HIP signal memory plus explicit graph batch-memory-op nodes

Validation

  • PyTorch 2.11.0 + ROCm 7.14 on one Radeon AI PRO R9700: targeted safety and replay suite, 21 passed
  • capture/replay exercised across 19 graph batch sizes, covering the former fixed-16-slot boundary
  • eager plus repeated graph replay covered bf16, MXFP4, DS-FP4, and GGUF Q4_0 CPU-MoE formats
  • ROCm 7.2.4 / PyTorch 2.10 compatibility build passed and remains fail-closed where graph batch-memory-op APIs are unavailable
  • CUDA module API and dispatch routing are covered by unit tests; NVIDIA hardware validation remains required before this Draft is marked ready
  • git diff --check passes

Draft exit criteria

  • reproduce the replay suite on RDNA3
  • complete repeated graph rebuild/replay and long-running serving stability checks
  • obtain NVIDIA CI coverage for the preserved CUDA path

bouclem and others added 10 commits August 24, 2026 13:53
- Add hip_compat.h shim mapping CUDA runtime API to HIP equivalents
- Update pinned_tensor.cpp to compile under both nvcc and hipcc
- Add ROCm detection in arch.py (is_rocm, get_rocm_gfx_arch, is_gfx11xx_family)
- Guard NVIDIA arch checks to return None on ROCm
- Skip nvcc version check in _toolchain.py when on ROCm
- Add ROCm build path in setup.py (ROCM_HOME, amdhip64, --offload-arch)
- Add _hip_cflags() in kernel/utils.py for JIT compilation on ROCm
- Add is_rocm() and driver_hip_version() in backend.py
- Add rocm-smi fallback in __main__.py for clangd generation
- Add TODO(ROCm) for NCCL->RCCL, flashinfer/sgl_kernel ROCm builds,
  Triton autotune RDNA3 tuning, PDL equivalent, hiprtc JIT cache
- Add AMD ROCm classifier in pyproject.toml
Fail closed to eager execution when the HIP stream-memory handshake cannot survive capture and replay. Add a ROCm 7.14 graph batch-memop path with executor-owned signal and parameter storage, dynamically size graph flag slots, preserve the existing CUDA module API, and cover the safety and multi-format replay paths.
samuelishida pushed a commit to samuelishida/FreeToken that referenced this pull request Sep 5, 2026
What:
- Remove .agents/learnings and .plans/rocm-consolidation files from the branch.
- Remove internal increment and plan-path references from source comments and public installation docs.
- Keep implementation comments that explain correctness, ownership, profiler intent, source attribution, or ROCm safety behavior.
- Clarify public ROCm documentation: gfx1100 has recorded serving smoke on ROCm 7.2.1; the ROCm 7.14.x container is a reference environment, and other target cells remain compile-only until physical serving evidence exists.

Why:
- Keep merge surface focused on code, tests, reproducibility tooling, and user-facing documentation.
- Prevent private planning history, review workflow language, stale plan paths, and local process notes from entering the upstream repository.
- Avoid presenting compile success or a reference container as cross-target serving or performance proof.

Related upstream work informing this branch:
- PR FlashML-org#132: portable ROCm/HIP foundation.
- PR FlashML-org#133: TVM-FFI index/store portability.
- PR FlashML-org#135: RCCL tensor-parallel communication.
- PR FlashML-org#136: native GGUF ROCm build and Q4_0 kernels.
- PR FlashML-org#137: earlier AMD serving bring-up.
- PR FlashML-org#217: source-fork ROCm, Qwen3.5 GGUF, and performance experiments.
- PR FlashML-org#241: gfx1150 build, JIT, Triton, and attention hardening.
- PR FlashML-org#260: gfx1151 validation and fallback/build evidence.
- PR FlashML-org#316: HIP graph-capture-safe expert copies.
- PR FlashML-org#378: CPU/Hybrid MoE graph replay safety.
- Local branch milestones: 436263f, 926c1e8, e1d1856, 8a70c7e, and e5fd30f.

Evidence:
- 170 focused tests passed after cleanup.
- gfx1100 is the only target with end-to-end Qwen3.5 GGUF serving smoke recorded here.
- Remaining matrix targets are compile-only; no new throughput claim is published without a matching A/B manifest.
@lukascechovic

Copy link
Copy Markdown

Thanks for building this — we filed #350 and did not expect a fix, let alone one validated on the
same card we found it on. Your framing of the fix as fail-closed — disable capture and
continue on the correct eager path when the native handshake does not pass a real
capture/instantiate/replay probe — is the property we would have asked for and did not know how to
ask for. A silent wrong answer converted into a slower right one is exactly the right trade for
this class of bug.

Two things below: a ROCm datapoint your HIP memop work bears on directly, and an offer.

1. Your HIP memop port has a second consumer, and it is the half that is still private

Reading the diff, the thing that struck us is that the HIP hipStreamWriteValue64 /
hipStreamWaitValue64 / hipStreamBatchMemOp port is already here — we had it filed on our
own side as a piece of work someone would eventually have to do. It matters to us because the CPU
MoE executor is not the only thing in this engine that wants that handshake.

#311's disk-streamed PLE table resolves its stream memops by dlopening libcuda.so.1. On
ROCm that dlopen fails, the probe returns false, and the table falls back to launch-gating: a
blocking D2H of the sampled token that drains the stream, then hash, io_uring read, H2D and launch
on the host, with the GPU idle across the whole stretch. Measured on our box — half-box (one
R9700, TP=1), on tree c4b96fc1 (upstream 4b94bdc3 plus our patches 0001..0009), with
--ple-backend the only flag changed between the arms:

arm decode at 10k decode at 100k
--ple-backend pinned 26.100 ± 0.351 tok/s (n=8/10) 26.345 ± 0.110 (n=2/3)
--ple-backend disk 18.984 ± 0.272 tok/s (n=9/10) 19.432 ± 0.182 (n=2/3)

n is post-filter: replies are dropped if their zlib compression ratio falls below 0.40, which is
how we catch a degenerate repeated-token generation before it flatters a decode number. Note the
100k pair rests on two samples per arm — we quote it because it agrees with the 10k pair, not
as an independent result.

−27.3 % at 10k, −26.2 % at 100k, roughly 14 ms per token, against the −2.6 % … +0.2 %
band #311 reports on H100 and RTX PRO 6000 Blackwell — which was measured on the wait-sync
path. Prefill is unaffected: TTFT +0.20 s at 10k and −0.22 s at 100k, same half-box arms —
though every prefill number from this box carries "gfx1201, BLOCK_N clamped to 32" (see
Platform), and block_n = 64 does not run here at all — it raises OutOfResources before
executing an instruction — so the clamp's cost is unmeasurable rather than zero. The memory win is
real and large — on the same arms the PLE table stops pinning 47.7 GiB of host RAM — which is
why we run it anyway.

We can only bound the split, not measure it. Sampled from /proc/diskstats on the same
half-box disk arm, during decode the NVMe served 300 reads/s at 0.648 ms average = 16 reads per
token, one 4K O_DIRECT read per row. Fully serialised that is 10.4 ms; if the store's io_uring
batch puts all 16 in flight together it is ~0.65 ms. /proc/diskstats cannot tell those apart, so
the disk accounts for 0.65–10.4 ms and the launch-gating stall for the remaining 4–13.7
ms
. What the bound does establish is that the disk alone cannot reach 14 ms even in its worst
case. Splitting it properly needs a wall-clock inside the fill, which we have not written — so
treat the split as bounded, not measured.

Where your PR lands on it: the eager half is now reachable — on ROCm your module-level
memops_probe / memop_submit / memop_sync resolve to the HIP implementations, so a second
consumer could stop dlopening libcuda and just call them. The half that is still
CpuMoeExecutor-owned is the capture half — and that is the half #311 needs, since by its own
description the decode graph launches first and waits on the flag right before consuming the rows.
If the ROCm graph-node splicing ever becomes shareable, there is a caller waiting for it.

And the ROCm-7.14 fact you documented in that file — that ordinary hipStreamWrite/WaitValue
and hipStreamBatchMemOp calls execute during capture but are not recorded in the graph, so
you add explicit batch-memory-op nodes and splice them into the dependency set — is worth flagging
loudly to anyone attempting a second port. Someone who ports the eager calls, sees them work, and
captures a graph gets silently stale values on replay: the same failure shape as #350 itself, in a
different file.

None of this is a request. It is a note that the two land in the same place.

2. An offer against your draft-exit criteria

You list "reproduce the replay suite on RDNA3", "complete repeated graph rebuild/replay and
long-running serving stability checks"
, and NVIDIA CI for the preserved CUDA path.

We can help with parts of the middle one, on RDNA4 rather than RDNA3. We run two Radeon AI
PRO R9700 (gfx1201) on Linux under ROCm 7.14 / PyTorch 2.11, serving a 100 GiB-class MoE
continuously, and we can run your targeted safety and replay suite
(tests/engine/test_cpu_moe_graph_safety.py and the tests/moe/ graph-replay cases) on both
cards and report the numbers the way we did on #132.

We can also exercise it at TP=2. To be exact about what is new there: we have reported TP=2
serving before, on #135, so the gap is not TP=2 as such — it is that nobody has run the
CPU/Hybrid graph-replay path at TP>1, on any card. That is worth closing, because from our own
experience a caught per-rank exception behaves very differently at TP=2 than at TP=1. That
difference is a scheduler problem rather than yours, and we have written it up separately.

Three honest caveats before you count on any of it.

We cannot give you the "long-running serving stability" half. We serve --moe-backend offload, not cpu or hybrid, so a serving soak on this code path is not something our
production traffic would ever produce — it would be a rig we stand up specially, and it would not
be representative of a real workload the way our offload row is. Repeated rebuild/replay and the
suite are the parts we can do well.

We cannot promise a date. The box is a shared machine with a live soak on it. Say what you
want run and we will fit it in.

It is a second gfx1201 datapoint, not a clean-main CI result. RDNA4, on a tree that is
upstream 4b94bdc3 plus a local patch series, with the BLOCK_N clamp in the Platform note
below.

No quality claim from us in any case — we have no fidelity instrument on this box, which is why
#350 was reported as a numerical-deviation finding with a relative error rather than as a quality
regression.


Platform

2 × AMD Radeon AI PRO R9700 (gfx1201, 32 GiB each), Linux, ROCm 7.14, PyTorch 2.11, 6 physical
cores / 122 GiB host RAM. Serving qwen4_exp / Qwen3.8-Flash-Next NVFP4 with --moe-backend offload; the CPU/Hybrid finding in #350 came from running this repo's kernel suite on gfx1201,
not from this serving configuration.

Our tree is upstream 4b94bdc3 plus a local patch series, whose build asserts tree c4b96fc1
for the 0001..0009 stack every §1 number was measured on. The QSA prefill tile is halved until 2 · head_dim · block_n · itemsize fits a 60 KiB LDS budget (#349) — at this checkpoint's head_dim = 256 in bf16 that lands on BLOCK_N = 32. So every prefill number here is on the clamped tile
and is not comparable to a CUDA run of the same engine.

Box labelling, since this box has two cards: every number in §1 — decode, TTFT, the 47.7 GiB
and the /proc/diskstats figures alike — is half-box
, one R9700 at TP=1, on tree c4b96fc1
(upstream 4b94bdc3 plus our patches 0001..0009), with --ple-backend the only flag changed
between the arms. Anything we quote at --tp-size 2 is full-box. Every number is named
against the tree it was measured on, and none of them is measured on upstream main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ROCm] CpuMoeExecutor decode is silently wrong under CUDA-graph replay (rel err 1.36, no exception)

4 participants